TLDR: Escribí un bot de resolución de Wordle con Javascript y UIlicious. Puede volver a ejecutar o editar este fragmento cualquier día para obtener su solución diaria de Wordle. ¡Prueba y ve si puedes obtener una mejor puntuación que el bot! ¡Siéntete libre de editarlo y optimizar el algoritmo del solucionador!
Divulgación completa: soy el cofundador y CTO de Uilicious.com (presentado en este artículo)
El solucionador de wordler está cubierto en 3 partes, que cubre el
- Código de interacción de la interfaz de usuario (vinculado aquí)
- Modelo estadístico de Wordle y las matemáticas detrás de él (este artículo)
- Pruebas unitarias y benchmarking del wordle solver (@todo)
Todos los ejemplos estadísticos se pueden encontrar en el siguiente enlace: https://uilicio.us/wordle-statistics-sample Y se genera a través del código utilizado aquí: https://github.com/uilicious/wordle-solver-and-tester
Descargo de responsabilidad, no pretendo que esta sea la mejor estrategia de WORDLE (todavía), pero es bastante buena =)
Antes de entrar en las estadísticas, primero cubramos nuestra estrategia WORLDE.
Una cosa que los humanos hacen muy bien y que las computadoras clásicas son malas es resolver las cosas "intuitivamente". A menos que planee entrenar una red neuronal, el programa de computadora que estoy desarrollando necesitará usar una lista de palabras del diccionario clásico para hacer sus conjeturas.
Sin embargo, lo único que las computadoras hacen bien es memorizar listas gigantes de palabras o datos. Y haciendo matemáticas en él. Así que usemos esto a nuestro favor haciendo la siguiente secuencia.
Dada la lista de 2 palabras, una llena de posibles respuestas (~2.3k palabras) y otra con la lista completa de palabras (13k palabras)...
Filtre las palabras en la lista de respuestas posibles contra el estado actual del juego a partir de conjeturas anteriores.
Cuente el número de veces que aparece cada carácter en la lista de palabras de respuesta en sus respectivas posiciones de palabra.
De la lista completa de palabras, elija la palabra que tenga más probabilidades de encontrar un carácter correcto. Puntúelo de forma independiente, prefiriendo las palabras que brindan más información en las primeras 4 rondas, ya sea con una coincidencia exacta o parcial.
Elija la palabra con la puntuación más alta y pruébela.
Repita desde arriba si es necesario.
También para aclarar: no memorizamos las soluciones anteriores de Wordle (sentí que era una trampa, ya que el sistema podría terminar memorizando la lista del día a día en secuencia).
Mientras que los detalles exactos de la puntuación cambian ligeramente según la ronda, para optimizar las cosas. No cambia el concepto general en un alto nivel.
Entonces, ¿cómo funciona esto en la práctica? Para nuestra iteración actual de la estrategia de Wordle, veremos esto en la práctica paso a paso (sin código).
Un alias para sain, que significa: hacer la señal de la cruz sobre (uno mismo) para bendecir o proteger del mal o del pecado.
Ha habido muchos consejos sobre las 2 palabras iniciales. Y tiene sentido que esta palabra siga con:
Pero averigüemos por qué el solucionador elige esta palabra mirando los números.
Basado en las estadísticas. "SAINE" tiene la mayor probabilidad de encontrar una coincidencia exacta de caracteres verdes como palabra de apertura si tiene 3 de las vocales principales.
Leer la tabla de distribución sin procesar puede ser comprensiblemente difícil de digerir. Así que permítanme recontextualizar esos números aquí. SAINE tiene un…
Hay una probabilidad muy alta de obtener al menos una o dos pistas principales. Inversamente, debido a que hay pocas palabras sin A, I y E, la falta de coincidencia es una "gran pista".
No está mal para una apertura, ¿eh?
¿Qué pasa con otras palabras de apertura populares como "CRANE" y "ADEPT"?
La única ventaja clave para "CRANE / ADEPT" es que ambos tienen un 0,04 % de posibilidades de adivinar con éxito una palabra. Sospecho que la falla del análisis público anterior fue cómo estaban limitando la palabra de apertura a la lista de respuestas conocidas. Sin embargo, creo que deberíamos usar la lista completa de palabras en su lugar, para maximizar las probabilidades de pistas a favor de una probabilidad muy pequeña de hacer una conjetura de 1 palabra.
Más importante aún, SAINE tiene una probabilidad significativamente mayor (en ~7 %) de adivinar una coincidencia exacta (verde) en el primer intento. Lo cual es increíblemente útil como pista.
Con el debate de la palabra inicial fuera del camino, ¡veremos cómo reacciona el sistema con los diferentes resultados!
Así que veamos cómo se elige la segunda palabra para la respuesta "PAUSA" (lado izquierdo).
Nos dan la siguiente información:
La letra I & N no está en la palabra.
A es el segundo carácter, E es el quinto carácter.
S es el tercer o cuarto carácter (no es el primer carácter).
Solo 12 respuestas posibles están en la lista de palabras.
Cosas bastante estándar de Wordle. Pero veamos cómo afecta esto a las estadísticas de las posibles respuestas restantes. Y cómo se eligió “PLUTO”.
Con solo 12 palabras restantes, busquemos la forma más eficiente de eliminar nuestras opciones.
De las siguientes restricciones, la única palabra válida para probar era PLUTÓN. Aunque sabemos que la letra O no está en la respuesta final, no hay una palabra para "PLUTC". Además, aunque la palabra PLUTÓN no estaba en la lista de respuestas, estaba en la lista completa de palabras, lo que la convertía en una suposición válida.
Después del envío, el sistema ahora sabe lo siguiente:
Esto significa que ya no necesito estadísticas porque solo hay una verdad: PAUSA.
El Mohur es una moneda de oro que fue acuñada anteriormente por varios gobiernos, incluida la India británica y algunos de los estados principescos que existían a su lado.
Las estadísticas aquí se evalúan en vivo y los cambios dependen del resultado de cada ronda. Entonces el resultado difirió donde:
Las letras S, A, I, N no están en la palabra.
La letra E puede estar en las posiciones 1, 2, 3, 4 pero no en la 5.
Filtrar la lista de posibles respuestas conduce a las siguientes estadísticas:
Esto puede no tener sentido al principio porque la palabra no comienza con C o B. Con 187 palabras restantes, aquí es donde los detalles de la puntuación comienzan a importar.
Esta puede no ser la opción más óptima dada la lista de respuestas conocidas. Sin embargo, esto es intencional porque queremos centrarnos en la información nuevamente hasta que tengamos una confianza muy alta en una palabra. Y en este proceso, penalizaríamos las letras duplicadas para centrarnos en reducir la cantidad de opciones. (Posiblemente, hay espacio para mejoras aquí)
Los resultados de la conjetura fueron interesantes.
Por extraña y confusa que fuera la palabra MOHUR, el resultado redujo la posibilidad a 12 palabras. Una vez más, intenta priorizar probar nuevos personajes y darle la oscura palabra de BLYPE.
BLYPE: un trozo o tira de piel
Esta palabra reduce la lista de posibilidades a una palabra: ÚLCERA, que es la respuesta final.
Nota al margen: si nota, está dispuesto a probar personajes que sabe que no están en la lista de respuestas oficial. Esto es intencional. Tenga en cuenta los clones de Wordle porque si la respuesta real elegida no está dentro de la lista de respuestas original, el sistema automáticamente volverá a usar la lista de palabras completa en su lugar. Haciendo esto más resistente contra las variantes de Wordle.
⚠️ Advertencia de código por delante: si solo estuvo aquí para las matemáticas y las estadísticas, salte hasta el final. El contenido restante de este artículo consiste en código JS.
La clase de resolución completa se puede encontrar aquí .
Este artículo se centrará en la funcionalidad principal que se requiere para que este proceso funcione y no en todas las partes del código. Si no ha leído la Parte 1, léala aquí .
El código aquí se ha simplificado para omitir las cosas "repetitivas".
El solucionador debe hacer lo siguiente:
Vamos a desglosarlo, pieza por pieza.
Para cada ronda, el estado del juego se generaría con:
Esto se genera utilizando la información en la pantalla en la parte 1.
Sin embargo, para nuestro caso de uso, necesitaríamos normalizar algunos de los conjuntos de datos comunes que necesitaríamos. A través de la función _normalizeStateObj
, obtenemos lo siguiente.
Esto se genera fácilmente iterando .history
y los datos .pos
para construir primero la lista de buenos personajes. Luego, use eso para construir la lista de personajes malos inversamente contra la lista histórica de palabras.
/** * Given the state object, normalize various values, using the minimum "required" value. * This does not provide as much data as `WordleSolvingAlgo` focusing on the minimum required * to make the current system work * * @param {Object} state * * @return {Object} state object normalized */ function _normalizeStateObj( state ) { // Setup the initial charset state.badCharSet = new Set(); state.goodCharSet = new Set(); // Lets build the good charset for(let i=0; i<state.wordLength; ++i) { if( state.pos[i].foundChar ) { state.goodCharSet.add(state.pos[i].foundChar); } for(let char of state.pos[i].hintSet) { state.goodCharSet.add(char); } } // Lets iterate history and build badCharSet for(let i=0; i<state.history.length; ++i) { const word = state.history[i]; for( let w=0; w<word.length; ++w ) { // check the individual char let char = word.charAt(w); // If char is not in good set if( !state.goodCharSet.has(char) ) { // its in the bad set state.badCharSet.add(char); } } } // Return the normalize state object return state; }
Ahora que tenemos el estado actual del juego, veamos cómo filtrar la lista de palabras:
/** * Given the wordList, filter only for possible answers, using the state object. * And returns the filtered list. This function just returns the wordList, if state == null * @param {Array<String>} wordList * @param {Object} state */ function filterWordList( wordList, state ) { // Skip if its not setup if( state == null || wordList.length <= 0 ) { return wordList; } // Get the word length const wordLength = wordList[0].length; // Filter and return return wordList.filter(function(s) { // Filtering logic // .... // all checks pass, return true return true; }); }
Para la lógica de filtrado, primero eliminamos las palabras dentro de badCharSET.
// filter out invalid words (aka hard mode) for(const bad of state.badCharSet) { // PS : this does nothing if the set is empty if(s.includes(bad)) { return false; } }
Seguido de filtrar las palabras con ubicaciones de pistas incorrectas:
// filter out words with wrong hint locations, for each character position for(let i=0; i<wordLength; ++i) { // Get the word character let sChar = s.charAt(i); // Check if the chracter, conflicts with an existing found char (green) if(state.pos[i].foundChar && sChar != state.pos[i].foundChar) { return false; } // Check if the character is already a known mismatch (yellow, partial match) // for each position for(const bad of state.pos[i].hintSet) { if(sChar == bad) { return false; } } }
Para las siguientes palabras sin todas las coincidencias encontradas conocidas (exactas y parciales):
// filter out words WITHOUT the hinted chars // PS : this does nothing if the set is empty for(const good of state.goodCharSet) { if(!s.includes(good)) { return false; } }
Además, tenemos una variante para filtrar palabras únicas para filterForUniqueWordList
. Esto no tiene duplicados de caracteres y se usa en las primeras rondas:
let wordCharSet = new Set(); // iterate the characters for(const char of s) { // Update the word charset wordCharSet.add(char); } // There is duplicate characters if( wordCharSet.size != s.length ) { return false; }
Después de filtrar todas las posibles respuestas restantes, las estadísticas se generan a través charsetStatistics( dictArray )
Esto se hace construyendo un objeto para el tipo de estadísticas. Iterar la lista de palabras e incrementar los números:
/** * Analyze the given dictionary array, to get character statistics * This will return the required statistics model, to be used in guessing a word. * * Which is provided in 3 major parts, using an object, which uses the character as a key, followed by its frequency as a number * * - overall : Frequency of apperance of each character * - unique : Frequency of apperance of each character per word (meaning, duplicates in 1 word is ignored) * - positional : An array of object, which provides the frequency of apperance unique to that word position * * Note that because it is possible for the dataset to not have characters in the list / positional location, * you should assume any result without a key, means a freqency of 0 * * @param {Array<String>} dictArray - containg various words, of equal length * * @return Object with the respective, overall / unique / positional stats **/ charsetStatistics( dictArray ) { // Safety check if( dictArray == null || dictArray.length <= 0 ) { throw `Unexpected empty dictionary list, unable to perform charsetStatistics / guesses`; } // The overall stats, for each character let overallStats = {}; // The overall stats, for each unique charcter // (ignore duplicates in word) let overallUniqueStats = {}; // The stats, for each character slot let positionalStats = []; // Lets initialize the positionalStats let wordLen = dictArray[0].length; for(let i=0; i<wordLen; ++i) { positionalStats[i] = {}; } // Lets iterate the full dictionary for( const word of dictArray ) { // Character set for the word const charSet = new Set(); // For each character, populate the overall stats for( let i=0; i<wordLen; ++i ) { // Get the character const char = word.charAt(i); // Increment the overall stat this._incrementObjectProperty( overallStats, char ); // Populate the charset, for overall unique stats charSet.add( char ); // Increment each positional stat this._incrementObjectProperty( positionalStats[i], char ); } // Populate the unique stats for( const char of charSet ) { // Increment the overall unique stat this._incrementObjectProperty( overallUniqueStats, char ); } } // Lets return the stats obj return { overall: overallStats, unique: overallUniqueStats, positional: positionalStats } }
Esto es bastante sencillo para los bucles en cada palabra y cada incremento de carácter dentro del recuento estadístico respectivo.
El único problema es que no podemos hacer un incremento de ++ en una propiedad de objeto cuando no está inicializado. Esto daría como resultado el siguiente error:
// This will give an exception for // TypeError: Cannot read properties of undefined (reading 'a') let obj; obj["a"]++;
Entonces, necesitaríamos usar una función de ayuda simple para incrementar nuestro caso de uso necesario correctamente:
/** * Increment an object key, used at various stages of the counting process * @param {Object} obj * @param {String} key **/ _incrementObjectProperty( obj, key ) { if( obj[key] > 0 ) { obj[key]++; } else { obj[key] = 1; } }
En el corazón del solucionador está la lógica de puntuación. Que se clasifica en cada entrada de palabra posible con las estadísticas y el estado dados.
Descargo de responsabilidad: no afirmo que esta sea la función de puntuación de palabras más óptima que existe para Wordle. Definitivamente se puede mejorar, pero es bastante bueno según mis pruebas hasta ahora. =)
/** * The heart of the wordle solving system. * * @param {Object} charStats, output from charsetStats * @param {String} word to score * @param {Object} state object (to refine score) * * @return {Number} representing the word score (may have decimal places) **/ function scoreWord( charStats, word, state = null ) { // Character set for the word, used to check for uniqueness const charSet = new Set(); // the final score to return let score = 0; // Wordle Strategy note: // // - Penalize duplicate characters, as they limit the amount of information we get // - Priotize characters with high positional score, this helps increase the chances of "exact green matches" early // reducing the effort required to deduce "partial yello matches" // - If there is a tie, in positional score, tie break it with "unique" score and overall score // this tends to be relevent in the last <100 matches // // - We used to favour positional score, over unique score in the last few rounds only // but after several trial and errors run, we found it was better to just use positonal score all the way // Lets do scoring math // ... // Return the score return score; }
Esto pasa por varias etapas: Primero, agregamos una red de seguridad para evitar que el sistema sugiera una palabra nuevamente (puntuación negativa enorme).
// Skip attempted words - like WHY ??? if( state && state.history ) { if( state.history.indexOf(word) >= 0 ) { return -1000*1000; } }
Luego iteramos cada carácter de la palabra y los calificamos respectivamente:
// For each character, populate the overall stats for( let i=0; i<word.length; ++i ) { // Get the character const char = word.charAt(i); // Does scoring for each character // ... }
Penalización de palabras con caracteres repetidos o caracteres conocidos:
// skip scoring of known character matches // or the attempted character hints if( state ) { // Skip known chars (good/found) if( state.pos && state.pos[i].foundChar == char ) { score += -50; charSet.add( char ); continue; } // Skip scoring of duplicate char if( charSet.has( char ) ) { score += -25; continue; } // Skip known chars (good/found) if( state.goodCharSet && state.goodCharSet.has(char) ) { score += -10; charSet.add( char ); continue; } } else { // Skip scoring of duplicate char if( charSet.has( char ) ) { score += -25; continue; } } // Populate the charset, we check this to favour words of unique chars charSet.add( char );
Finalmente, calculamos el puntaje para cada estadística posicional con el puntaje de carácter único que se usa como criterio de desempate:
// Dev Note: // // In general - we should always do a check if the "character" exists in the list. // This helps handle some NaN situations, where the character has no score // this is possible because the valid list will include words, that can be inputted // but is not part of the filtered list - see `charsetStatistics` if( charStats.positional[i][char] ) { score += charStats.positional[i][char]*10000; } if (charStats.unique[char]) { score += charStats.unique[char] } // -- Loops to the next char -- //
Ahora que tenemos la función de puntuación, podemos comenzar a juntar todas las partes para la función "suggestWord".
Tenemos estadísticas que luego se pueden usar para calificar palabras. Ahora, juntémoslo para sugerir la mejor palabra de puntuación.
Comenzamos con recibir el estado del juego:
/** * Given the minimum state object, suggest the next word to attempt a guess. * * --- * # "state" object definition * * The solver, requires to know the existing wordle state information so this would consist of (at minimum) * * .history[] : an array of past wordle guesses * .pos[] : an array of objects containing the following info * .hintSet : set of characters that are valid "hints" * .foundChar : characters that are confirmed for the given position * * The above is compliant with the WordleAlgoTester state object format * Additional values will be added to the state object, using the above given information * --- * * @param {Object} state * * @return {String} word guess to perform */ suggestWord( state ) { // Normalize the state object state = this._normalizeStateObj(state); // Let'sLets get the respective wordlist let fullWordList = this.fullWordList; let filteredWordList = this.filterWordList( this.filteredWordList, state ); let uniqueWordList = this.filterForUniqueWords( this.uniqueWordList, state ); // As an object let wordList = { full: fullWordList, unique: uniqueWordList, filtered: filteredWordList }; // Lets do work on the various wordlist, and state // this is refactored as `suggestWord_fromStateAndWordList` // in the code base // .... }
Una vez que tenemos los diversos estados del juego y las listas de palabras, podemos decidir sobre la "lista de palabras de estadísticas", que usaremos para generar el modelo de estadísticas.
// Let's decide on which word list we use for the statistics // which should be the filtered word list **unless** there is // no possible answers on that list, which is possible when // the system is being used against a WORDLE variant // // In such a case, lets fall back to the filtered version of the "full // word list", instead of the filtered version of the "answer list". let statsList = wordList.filtered; if( wordList.filtered == null || wordList.filtered.length <= 0 ) { console.warn("[WARNING]: Unexpected empty 'filtered' wordlist, with no possible answers : falling back to full word list"); statsList = this.filterWordList( wordList.full, state ); } if( wordList.filtered == null || wordList.filtered.length <= 0 ) { console.warn("[WARNING]: Unexpected empty 'filtered' wordlist, with no possible answers : despite processing from full list, using it raw"); statsList = wordList.full; }
Una vez que decidimos la lista de palabras de estadísticas, recibimos las estadísticas:
// Get the charset stats const charStats = this.charsetStatistics(statsList);
Ahora decidimos la lista de palabras que usaremos para decidir una palabra. Nos referimos a esta lista como "lista anotada".
En las primeras rondas, nuestro objetivo es utilizar palabras únicas tanto como sea posible. Que no incluiría personajes que hemos probado anteriormente. Esto puede incluir palabras que sabemos que no están en la lista de posibles respuestas.
Esto es intencional, ya que estamos optimizando para obtener información, pero en lugar de eso, se trata de una pequeña posibilidad aleatoria de éxito temprano.
Sin embargo, cuando se vacíe o el juego esté en las últimas rondas, volveremos a la lista completa. Durante la ronda final, siempre adivinaremos usando la lista filtrada cuando sea posible: (solo dé nuestra mejor respuesta).
// sort the scored list, use unique words in first few rounds let scoredList = wordList.unique; // Use valid list from round 5 onwards // or when the unique list is drained if( scoredList.length == 0 || state.round >= 5 ) { scoredList = wordList.full; } // Use filtered list in last 2 round, or when its a gurantee "win" if( wordList.filtered.length > 0 && // (wordList.filtered.length < state.roundLeft || state.roundLeft <= 1) // ) { scoredList = wordList.filtered; }
Una vez que decidimos en la lista de puntuación para aplicar las estadísticas, marquemos y ordenemos:
// Self reference const self = this; // Score word sorting scoredList = scoredList.slice(0).sort(function(a,b) { // Get the score let bScore = self.scoreWord( charStats, b, state, finalStretch ); let aScore = self.scoreWord( charStats, a, state, finalStretch ); // And arrange them accordingly if( bScore > aScore ) { return 1; } else if( bScore == aScore ) { // Tie breakers - rare // as we already have score breakers in the algori if( b > a ) { return 1; } else if( a > b ) { return -1; } // Equality tie ??? return 0; } else { return -1; } });
Y devolver el elemento de mayor puntuación:
// Return the highest scoring word guess return scoredList[0];
Con el código de interacción de la interfaz de usuario realizado en la parte 1. Presione el botón "Ejecutar" para ver cómo funciona nuestro bot de Wordle.
Oye, no está mal, ¡mi bot resolvió el Wordle de hoy!
Porque los bots utilizarán una técnica bastante "inhumana" de cálculo de probabilidades con diccionarios gigantes.
La mayoría de los humanos encontrarán que esto está en el límite de hacer conjeturas realmente extrañas y locas. Cree en las matemáticas porque funcionan.
Mientras juegas para el equipo humano, la conclusión de este artículo es que deberías comenzar con la palabra "SAINE", o las palabras que quieras.
¡Depende de ti ya que este es tu juego después de todo! =) Diviértete.
¡Feliz Wordling! 🖖🏼🚀
Publicado por primera vez aquí